SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.6 KB · 112 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Bars } from '@/components/charts/bars';5import { LineChart } from '@/components/charts/line-chart';6import { Sparkline } from '@/components/charts/sparkline';7import { CompanyTable } from '@/components/company/company-table';8import { EventList } from '@/components/events/event-row';9import { EventTypeBadge } from '@/components/ui/badges';10import { Container, Empty, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';11import { api, ApiError, safe } from '@/lib/api';12import { countryName } from '@/lib/countries';13import { fmtInt, fmtPctSigned, fmtScore } from '@/lib/format';14import { routes } from '@/lib/site';15import type { CompanyCard, IndustryDetailRaw } from '@/lib/types';1617export const revalidate = 300;1819async function load(slug: string): Promise<IndustryDetailRaw> {20  try {21    return await api.industry(slug);22  } catch (e) {23    if (e instanceof ApiError && e.notFound) notFound();24    throw e;25  }26}27export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {28  const { slug } = await params;29  const d = await safe(api.industry(slug));30  if (!d) return { title: 'Industry' };31  return { title: `${d.name} — industry atlas`, description: `${d.name}: ${Array.isArray(d.companies) ? d.companies.length : d.companies} monitored companies, ${d.events_30d} structured events in 30 days, hiring momentum ${fmtPctSigned(d.hiring_momentum_30d)}.`, alternates: { canonical: `/industry/${slug}` } };32}3334export default async function IndustryPage({ params }: { params: Promise<{ slug: string }> }) {35  const { slug } = await params;36  const d = await load(slug);37  const companies: CompanyCard[] = Array.isArray(d.companies) ? d.companies : [];38  const count = Array.isArray(d.companies) ? d.companies.length : d.companies;39  const countries = d.countries ?? [];40  return (41    <Container wide>42      <PageHeader43        eyebrow={44          <>45            <Link href={routes.industries()} className="hover:text-ink">46              Industry atlas47            </Link>48            {d.parent_slug && (49              <>50                <span>/</span>51                <Link href={routes.industry(d.parent_slug)} className="hover:text-ink">52                  {d.parent_slug.replace(/-/g, ' ')}53                </Link>54              </>55            )}56          </>57        }58        title={d.name}59        lede={d.description ?? `Monitored companies classified under ${d.name}.`}60      />61      <StatGrid cols={6}>62        <Stat label="Companies" value={fmtInt(count)} size="sm" />63        <Stat label="Events 7 d" value={fmtInt(d.events_7d)} size="sm" />64        <Stat label="Events 30 d" value={fmtInt(d.events_30d)} size="sm" />65        <Stat label="Activity" value={fmtScore(d.activity_score)} size="sm" />66        <Stat label="Hiring 30 d" value={fmtPctSigned(d.hiring_momentum_30d)} size="sm" delta={d.hiring ? { value: `${fmtInt(d.hiring.open)} open`, tone: 'neutral' } : undefined} />67        <Stat label="AI adoption" value={fmtScore(d.ai_adoption)} size="sm" />68      </StatGrid>69      <div className="grid gap-8 lg:grid-cols-12">70        <Section eyebrow="Activity" title="Industry activity, 90 days" className="lg:col-span-8">71          {d.series?.length > 1 ? <LineChart series={[{ id: 'activity', label: 'Activity score', points: d.series.map((p) => ({ day: p.day, value: p.value })) }]} height={200} yZero /> : <Empty compact title="Not enough history yet." />}72        </Section>73        <Section eyebrow="Composition" title="Countries" className="lg:col-span-4">74          {countries.length ? <Bars dense rows={countries.slice(0, 10).map((c) => ({ key: c.country, label: countryName(c.country), value: c.companies, href: routes.country(c.country) }))} /> : <Empty compact />}75          {d.top_event_types?.length > 0 && (76            <p className="mt-4 flex flex-wrap items-center gap-1 text-xs text-ink-3">77              Top event types:78              {d.top_event_types.map((t) => (79                <EventTypeBadge key={t} type={t} small />80              ))}81            </p>82          )}83        </Section>84      </div>85      <Section eyebrow="Companies" title="Most active monitored companies" action={{ href: routes.companies({ industry: slug }), label: 'All in directory' }}>86        <CompanyTable items={companies} />87      </Section>88      <div className="grid gap-8 lg:grid-cols-12">89        <Section eyebrow="Events" title="Latest structured events" action={{ href: routes.events({ industry: slug }), label: 'All events' }} className="lg:col-span-8">90          <EventList events={d.events ?? []} variant="table" />91        </Section>92        <Section eyebrow="Trending" title="Terms gaining momentum" className="lg:col-span-4">93          {d.trending?.length ? (94            <ul className="divide-y divide-rule border-y border-rule text-sm">95              {d.trending.slice(0, 8).map((t) => (96                <li key={t.term} className="flex items-center gap-2 py-1.5">97                  <span className="min-w-0 flex-1 truncate">{t.term}</span>98                  <Sparkline values={t.series} width={48} height={14} />99                  <span className={`tnum text-xs ${(t.momentum ?? 0) > 0 ? 'text-positive' : 'text-ink-3'}`}>{fmtPctSigned(t.momentum, 0)}</span>100                </li>101              ))}102            </ul>103          ) : (104            <Empty compact />105          )}106          <Note className="mt-3">Hiring: {d.hiring ? `${fmtInt(d.hiring.new_30d)} new and ${fmtInt(d.hiring.removed_30d)} no-longer-listed roles in 30 days across monitored careers pages.` : 'summary unavailable.'}</Note>107        </Section>108      </div>109    </Container>110  );111}112